summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src/routes/workspace/[id]/usage
diff options
context:
space:
mode:
authorFrank <[email protected]>2026-03-11 00:39:56 -0400
committerFrank <[email protected]>2026-03-11 18:09:40 -0400
commited3bb3ea8f18a3d2818eb17d77e2caad40eb1dee (patch)
treec3916c942d7b24ee6a2d0930c76bfd422c24045c /packages/console/app/src/routes/workspace/[id]/usage
parentfac23a1afc6cfbb7767eef05bf6ea018837697ad (diff)
downloadopencode-ed3bb3ea8f18a3d2818eb17d77e2caad40eb1dee.tar.gz
opencode-ed3bb3ea8f18a3d2818eb17d77e2caad40eb1dee.zip
zen: add usage section
Diffstat (limited to 'packages/console/app/src/routes/workspace/[id]/usage')
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css145
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx515
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage/index.tsx21
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css185
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx217
5 files changed, 1083 insertions, 0 deletions
diff --git a/packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css
new file mode 100644
index 000000000..24b85be74
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css
@@ -0,0 +1,145 @@
+.root {
+ [data-component="empty-state"] {
+ padding: var(--space-20) var(--space-6);
+ text-align: center;
+ border: 1px dashed var(--color-border);
+ border-radius: var(--border-radius-sm);
+ height: 400px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ p {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ }
+ }
+
+ [data-slot="filter-container"] {
+ margin-bottom: 0;
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+
+ [data-component="dropdown"] {
+ [data-slot="trigger"] {
+ border: 1px solid var(--color-border);
+ background-color: var(--color-bg);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--border-radius-sm);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+
+ &:hover {
+ border-color: var(--color-accent);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px var(--color-accent-alpha);
+ }
+ }
+
+ [data-slot="chevron"] {
+ opacity: 0.6;
+ }
+
+ [data-slot="dropdown"] {
+ min-width: 200px;
+ max-height: 300px;
+ overflow-y: auto;
+ padding: var(--space-1);
+ }
+ }
+ }
+
+ [data-slot="month-picker"] {
+ display: flex;
+ align-items: center;
+ background-color: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ padding: 0;
+ }
+
+ [data-slot="month-button"] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: none !important;
+ color: var(--color-text);
+ cursor: pointer;
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--border-radius-xs);
+ transition: background-color 0.2s;
+ line-height: 1;
+
+ &:hover {
+ background-color: var(--color-bg-hover);
+ }
+
+ svg {
+ display: block;
+ width: 16px;
+ height: 16px;
+ stroke-width: 2;
+ }
+ }
+
+ [data-slot="month-label"] {
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--color-text);
+ line-height: 1.5;
+ min-width: 140px;
+ text-align: center;
+ white-space: nowrap;
+ }
+
+ [data-slot="model-item"] {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ cursor: pointer;
+ transition: background-color 0.2s;
+ font-size: var(--font-size-sm);
+ color: var(--color-text);
+ border: none !important;
+ background: none;
+ width: 100%;
+ text-align: left;
+ white-space: nowrap;
+
+ &:hover {
+ background: var(--color-bg-hover);
+ }
+
+ span {
+ flex: 1;
+ user-select: none;
+ }
+ }
+
+ [data-slot="chart-container"] {
+ padding: var(--space-6);
+ background: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ height: 400px;
+ }
+
+ @media (max-width: 40rem) {
+ [data-slot="chart-container"] {
+ height: 300px;
+ padding: var(--space-4);
+ }
+
+ [data-component="empty-state"] {
+ height: 300px;
+ }
+ }
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx
new file mode 100644
index 000000000..bb4b4f4cf
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx
@@ -0,0 +1,515 @@
+import { and, Database, eq, gte, inArray, isNull, lt, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js"
+import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
+import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
+import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
+import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
+import { useParams } from "@solidjs/router"
+import { createEffect, createMemo, onCleanup, Show, For } from "solid-js"
+import { createStore } from "solid-js/store"
+import { withActor } from "~/context/auth.withActor"
+import { Dropdown } from "~/component/dropdown"
+import { IconChevronLeft, IconChevronRight } from "~/component/icon"
+import styles from "./graph-section.module.css"
+import {
+ Chart,
+ BarController,
+ BarElement,
+ CategoryScale,
+ LinearScale,
+ Tooltip,
+ Legend,
+ type ChartConfiguration,
+} from "chart.js"
+import { useI18n } from "~/context/i18n"
+
+Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
+
+async function getCosts(workspaceID: string, year: number, month: number) {
+ "use server"
+ return withActor(async () => {
+ const startDate = new Date(year, month, 1)
+ const endDate = new Date(year, month + 1, 1)
+ const usageData = await Database.use((tx) =>
+ tx
+ .select({
+ date: sql<string>`DATE(${UsageTable.timeCreated})`,
+ model: UsageTable.model,
+ totalCost: sum(UsageTable.cost),
+ keyId: UsageTable.keyID,
+ plan: sql<string | null>`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
+ })
+ .from(UsageTable)
+ .where(
+ and(
+ eq(UsageTable.workspaceID, workspaceID),
+ gte(UsageTable.timeCreated, startDate),
+ lt(UsageTable.timeCreated, endDate),
+ ),
+ )
+ .groupBy(
+ sql`DATE(${UsageTable.timeCreated})`,
+ UsageTable.model,
+ UsageTable.keyID,
+ sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
+ )
+ .then((x) =>
+ x.map((r) => ({
+ ...r,
+ totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
+ plan: r.plan as "sub" | "lite" | "byok" | null,
+ })),
+ ),
+ )
+
+ // Get unique key IDs from usage
+ const usageKeyIds = new Set(usageData.map((r) => r.keyId).filter((id) => id !== null))
+
+ // Second query: get all existing keys plus any keys from usage
+ const keysData = await Database.use((tx) =>
+ tx
+ .select({
+ keyId: KeyTable.id,
+ keyName: KeyTable.name,
+ userEmail: AuthTable.subject,
+ timeDeleted: KeyTable.timeDeleted,
+ })
+ .from(KeyTable)
+ .innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID)))
+ .innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
+ .where(
+ and(
+ eq(KeyTable.workspaceID, workspaceID),
+ usageKeyIds.size > 0
+ ? or(inArray(KeyTable.id, Array.from(usageKeyIds)), isNull(KeyTable.timeDeleted))
+ : isNull(KeyTable.timeDeleted),
+ ),
+ )
+ .orderBy(AuthTable.subject, KeyTable.name),
+ )
+
+ return {
+ usage: usageData,
+ keys: keysData.map((key) => ({
+ id: key.keyId,
+ displayName: `${key.userEmail} - ${key.keyName}`,
+ deleted: key.timeDeleted !== null,
+ })),
+ }
+ }, workspaceID)
+}
+
+const MODEL_COLORS: Record<string, string> = {
+ "claude-sonnet-4-5": "#D4745C",
+ "claude-sonnet-4": "#E8B4A4",
+ "claude-opus-4": "#C8A098",
+ "claude-haiku-4-5": "#F0D8D0",
+ "claude-3-5-haiku": "#F8E8E0",
+ "gpt-5.1": "#4A90E2",
+ "gpt-5.1-codex": "#6BA8F0",
+ "gpt-5": "#7DB8F8",
+ "gpt-5-codex": "#9FCAFF",
+ "gpt-5-nano": "#B8D8FF",
+ "grok-code": "#8B5CF6",
+ "big-pickle": "#10B981",
+ "kimi-k2": "#F59E0B",
+ "qwen3-coder": "#EC4899",
+ "glm-4.6": "#14B8A6",
+}
+
+function getModelColor(model: string): string {
+ if (MODEL_COLORS[model]) return MODEL_COLORS[model]
+
+ const hash = model.split("").reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0)
+ const hue = Math.abs(hash) % 360
+ return `hsl(${hue}, 50%, 65%)`
+}
+
+function formatDateLabel(dateStr: string): string {
+ const date = new Date()
+ const [y, m, d] = dateStr.split("-").map(Number)
+ date.setFullYear(y)
+ date.setMonth(m - 1)
+ date.setDate(d)
+ date.setHours(0, 0, 0, 0)
+ const month = date.toLocaleDateString(undefined, { month: "short" })
+ const day = date.getUTCDate().toString().padStart(2, "0")
+ return `${month} ${day}`
+}
+
+function addOpacityToColor(color: string, opacity: number): string {
+ if (color.startsWith("#")) {
+ const r = parseInt(color.slice(1, 3), 16)
+ const g = parseInt(color.slice(3, 5), 16)
+ const b = parseInt(color.slice(5, 7), 16)
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`
+ }
+ if (color.startsWith("hsl")) return color.replace(")", `, ${opacity})`).replace("hsl", "hsla")
+ return color
+}
+
+export function GraphSection() {
+ let canvasRef: HTMLCanvasElement | undefined
+ let chartInstance: Chart | undefined
+ const params = useParams()
+ const i18n = useI18n()
+ const now = new Date()
+ const [store, setStore] = createStore({
+ data: null as Awaited<ReturnType<typeof getCosts>> | null,
+ year: now.getFullYear(),
+ month: now.getMonth(),
+ key: null as string | null,
+ model: null as string | null,
+ modelDropdownOpen: false,
+ keyDropdownOpen: false,
+ colorScheme: "light" as "light" | "dark",
+ })
+ const onPreviousMonth = async () => {
+ const month = store.month === 0 ? 11 : store.month - 1
+ const year = store.month === 0 ? store.year - 1 : store.year
+ setStore({ month, year })
+ }
+
+ const onNextMonth = async () => {
+ const month = store.month === 11 ? 0 : store.month + 1
+ const year = store.month === 11 ? store.year + 1 : store.year
+ setStore({ month, year })
+ }
+
+ const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false })
+
+ const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
+
+ const getModels = createMemo(() => {
+ if (!store.data?.usage) return []
+ return Array.from(new Set(store.data.usage.map((row) => row.model))).sort()
+ })
+
+ const getDates = createMemo(() => {
+ const daysInMonth = new Date(store.year, store.month + 1, 0).getDate()
+ return Array.from({ length: daysInMonth }, (_, i) => {
+ const date = new Date(store.year, store.month, i + 1)
+ return date.toISOString().split("T")[0]
+ })
+ })
+
+ const getKeyName = (keyID: string | null): string => {
+ if (!keyID || !store.data?.keys) return i18n.t("workspace.cost.allKeys")
+ const found = store.data.keys.find((k) => k.id === keyID)
+ if (!found) return i18n.t("workspace.cost.allKeys")
+ return found.deleted ? `${found.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : found.displayName
+ }
+
+ const formatMonthYear = () =>
+ new Date(store.year, store.month, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" })
+
+ const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
+
+ const chartConfig = createMemo((): ChartConfiguration | null => {
+ const data = store.data
+ const dates = getDates()
+ if (!data?.usage?.length) return null
+
+ store.colorScheme
+ const styles = getComputedStyle(document.documentElement)
+ const colorTextMuted = styles.getPropertyValue("--color-text-muted").trim()
+ const colorBorderMuted = styles.getPropertyValue("--color-border-muted").trim()
+ const colorBgElevated = styles.getPropertyValue("--color-bg-elevated").trim()
+ const colorText = styles.getPropertyValue("--color-text").trim()
+ const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
+ const colorBorder = styles.getPropertyValue("--color-border").trim()
+ const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
+ const liteSuffix = " (go)"
+
+ const dailyDataRegular = new Map<string, Map<string, number>>()
+ const dailyDataSub = new Map<string, Map<string, number>>()
+ const dailyDataLite = new Map<string, Map<string, number>>()
+ for (const dateKey of dates) {
+ dailyDataRegular.set(dateKey, new Map())
+ dailyDataSub.set(dateKey, new Map())
+ dailyDataLite.set(dateKey, new Map())
+ }
+
+ data.usage
+ .filter((row) => (store.key ? row.keyId === store.key : true))
+ .forEach((row) => {
+ const targetMap = row.plan === "sub" ? dailyDataSub : row.plan === "lite" ? dailyDataLite : dailyDataRegular
+ const dayMap = targetMap.get(row.date)
+ if (!dayMap) return
+ dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
+ })
+
+ const filteredModels = store.model === null ? getModels() : [store.model]
+
+ // Create datasets: regular first, then subscription, then lite (with visual distinction via opacity)
+ const datasets = [
+ ...filteredModels
+ .filter((model) => dates.some((date) => (dailyDataRegular.get(date)?.get(model) || 0) > 0))
+ .map((model) => {
+ const color = getModelColor(model)
+ return {
+ label: model,
+ data: dates.map((date) => (dailyDataRegular.get(date)?.get(model) || 0) / 100_000_000),
+ backgroundColor: color,
+ hoverBackgroundColor: color,
+ borderWidth: 0,
+ stack: "usage",
+ }
+ }),
+ ...filteredModels
+ .filter((model) => dates.some((date) => (dailyDataSub.get(date)?.get(model) || 0) > 0))
+ .map((model) => {
+ const color = getModelColor(model)
+ return {
+ label: `${model}${subSuffix}`,
+ data: dates.map((date) => (dailyDataSub.get(date)?.get(model) || 0) / 100_000_000),
+ backgroundColor: addOpacityToColor(color, 0.5),
+ hoverBackgroundColor: addOpacityToColor(color, 0.7),
+ borderWidth: 1,
+ borderColor: color,
+ stack: "subscription",
+ }
+ }),
+ ...filteredModels
+ .filter((model) => dates.some((date) => (dailyDataLite.get(date)?.get(model) || 0) > 0))
+ .map((model) => {
+ const color = getModelColor(model)
+ return {
+ label: `${model}${liteSuffix}`,
+ data: dates.map((date) => (dailyDataLite.get(date)?.get(model) || 0) / 100_000_000),
+ backgroundColor: addOpacityToColor(color, 0.35),
+ hoverBackgroundColor: addOpacityToColor(color, 0.55),
+ borderWidth: 1,
+ borderColor: addOpacityToColor(color, 0.7),
+ borderDash: [4, 2],
+ stack: "lite",
+ }
+ }),
+ ]
+
+ return {
+ type: "bar",
+ data: {
+ labels: dates.map(formatDateLabel),
+ datasets,
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ x: {
+ stacked: true,
+ grid: {
+ display: false,
+ },
+ ticks: {
+ maxRotation: 0,
+ autoSkipPadding: 20,
+ color: colorTextMuted,
+ font: {
+ family: "monospace",
+ size: 11,
+ },
+ },
+ },
+ y: {
+ stacked: true,
+ beginAtZero: true,
+ grid: {
+ color: colorBorderMuted,
+ },
+ ticks: {
+ color: colorTextMuted,
+ font: {
+ family: "monospace",
+ size: 11,
+ },
+ callback: (value) => {
+ const num = Number(value)
+ return num >= 1000 ? `$${(num / 1000).toFixed(1)}k` : `$${num.toFixed(0)}`
+ },
+ },
+ },
+ },
+ plugins: {
+ tooltip: {
+ mode: "index",
+ intersect: false,
+ backgroundColor: colorBgElevated,
+ titleColor: colorText,
+ bodyColor: colorTextSecondary,
+ borderColor: colorBorder,
+ borderWidth: 1,
+ padding: 12,
+ displayColors: true,
+ filter: (item) => (item.parsed.y ?? 0) > 0,
+ callbacks: {
+ label: (context) => `${context.dataset.label}: $${(context.parsed.y ?? 0).toFixed(2)}`,
+ },
+ },
+ legend: {
+ display: true,
+ position: "bottom",
+ labels: {
+ color: colorTextSecondary,
+ font: {
+ size: 12,
+ },
+ padding: 16,
+ boxWidth: 16,
+ boxHeight: 16,
+ usePointStyle: false,
+ },
+ onHover: (event, legendItem, legend) => {
+ const chart = legend.chart
+ chart.data.datasets?.forEach((dataset, i) => {
+ const meta = chart.getDatasetMeta(i)
+ const label = dataset.label || ""
+ const isSub = label.endsWith(subSuffix)
+ const isLite = label.endsWith(liteSuffix)
+ const model = isSub
+ ? label.slice(0, -subSuffix.length)
+ : isLite
+ ? label.slice(0, -liteSuffix.length)
+ : label
+ const baseColor = getModelColor(model)
+ const originalColor = isSub
+ ? addOpacityToColor(baseColor, 0.5)
+ : isLite
+ ? addOpacityToColor(baseColor, 0.35)
+ : baseColor
+ const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15)
+ meta.data.forEach((bar: any) => {
+ bar.options.backgroundColor = color
+ })
+ })
+ chart.update("none")
+ },
+ onLeave: (event, legendItem, legend) => {
+ const chart = legend.chart
+ chart.data.datasets?.forEach((dataset, i) => {
+ const meta = chart.getDatasetMeta(i)
+ const label = dataset.label || ""
+ const isSub = label.endsWith(subSuffix)
+ const isLite = label.endsWith(liteSuffix)
+ const model = isSub
+ ? label.slice(0, -subSuffix.length)
+ : isLite
+ ? label.slice(0, -liteSuffix.length)
+ : label
+ const baseColor = getModelColor(model)
+ const color = isSub
+ ? addOpacityToColor(baseColor, 0.5)
+ : isLite
+ ? addOpacityToColor(baseColor, 0.35)
+ : baseColor
+ meta.data.forEach((bar: any) => {
+ bar.options.backgroundColor = color
+ })
+ })
+ chart.update("none")
+ },
+ },
+ },
+ },
+ }
+ })
+
+ createEffect(async () => {
+ const data = await getCosts(params.id!, store.year, store.month)
+ setStore({ data })
+ })
+
+ createEffect(() => {
+ const config = chartConfig()
+ if (!config || !canvasRef) return
+
+ if (chartInstance) chartInstance.destroy()
+ chartInstance = new Chart(canvasRef, config)
+
+ onCleanup(() => chartInstance?.destroy())
+ })
+
+ createEffect(() => {
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
+ setStore({ colorScheme: mediaQuery.matches ? "dark" : "light" })
+
+ const handleColorSchemeChange = (e: MediaQueryListEvent) => {
+ setStore({ colorScheme: e.matches ? "dark" : "light" })
+ }
+
+ mediaQuery.addEventListener("change", handleColorSchemeChange)
+ onCleanup(() => mediaQuery.removeEventListener("change", handleColorSchemeChange))
+ })
+
+ return (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>{i18n.t("workspace.cost.title")}</h2>
+ <p>{i18n.t("workspace.cost.subtitle")}</p>
+ </div>
+
+ <div data-slot="filter-container">
+ <div data-slot="month-picker">
+ <button data-slot="month-button" onClick={onPreviousMonth}>
+ <IconChevronLeft />
+ </button>
+ <span data-slot="month-label">{formatMonthYear()}</span>
+ <button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
+ <IconChevronRight />
+ </button>
+ </div>
+ <Dropdown
+ trigger={store.model === null ? i18n.t("workspace.cost.allModels") : store.model}
+ open={store.modelDropdownOpen}
+ onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
+ >
+ <>
+ <button data-slot="model-item" onClick={() => onSelectModel(null)}>
+ <span>{i18n.t("workspace.cost.allModels")}</span>
+ </button>
+ <For each={getModels()}>
+ {(model) => (
+ <button data-slot="model-item" onClick={() => onSelectModel(model)}>
+ <span>{model}</span>
+ </button>
+ )}
+ </For>
+ </>
+ </Dropdown>
+ <Dropdown
+ trigger={getKeyName(store.key)}
+ open={store.keyDropdownOpen}
+ onOpenChange={(open) => setStore({ keyDropdownOpen: open })}
+ >
+ <>
+ <button data-slot="model-item" onClick={() => onSelectKey(null)}>
+ <span>{i18n.t("workspace.cost.allKeys")}</span>
+ </button>
+ <For each={store.data?.keys || []}>
+ {(key) => (
+ <button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
+ <span>
+ {key.deleted ? `${key.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : key.displayName}
+ </span>
+ </button>
+ )}
+ </For>
+ </>
+ </Dropdown>
+ </div>
+
+ <Show
+ when={chartConfig()}
+ fallback={
+ <div data-component="empty-state">
+ <p>{i18n.t("workspace.cost.empty")}</p>
+ </div>
+ }
+ >
+ <div data-slot="chart-container">
+ <canvas ref={canvasRef} />
+ </div>
+ </Show>
+ </section>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/usage/index.tsx b/packages/console/app/src/routes/workspace/[id]/usage/index.tsx
new file mode 100644
index 000000000..3a9c8db29
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/usage/index.tsx
@@ -0,0 +1,21 @@
+import { Show } from "solid-js"
+import { createAsync, useParams } from "@solidjs/router"
+import { GraphSection } from "./graph-section"
+import { UsageSection } from "./usage-section"
+import { querySessionInfo } from "../../common"
+
+export default function () {
+ const params = useParams()
+ const user = createAsync(() => querySessionInfo(params.id!))
+
+ return (
+ <div data-page="workspace-[id]">
+ <div data-slot="sections">
+ <Show when={user()?.isAdmin}>
+ <GraphSection />
+ </Show>
+ <UsageSection />
+ </div>
+ </div>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css
new file mode 100644
index 000000000..00232de88
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css
@@ -0,0 +1,185 @@
+.root {
+ /* Empty state */
+ [data-component="empty-state"] {
+ padding: var(--space-20) var(--space-6);
+ text-align: center;
+ border: 1px dashed var(--color-border);
+ border-radius: var(--border-radius-sm);
+
+ p {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ }
+ }
+
+ /* Table container */
+ [data-slot="usage-table"] {
+ overflow-x: auto;
+ }
+
+ /* Table element */
+ [data-slot="usage-table-element"] {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-sm);
+
+ thead {
+ border-bottom: 1px solid var(--color-border);
+ }
+
+ th {
+ padding: var(--space-3) var(--space-4);
+ text-align: left;
+ font-weight: normal;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ }
+
+ td {
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--color-border-muted);
+ color: var(--color-text-muted);
+ font-family: var(--font-mono);
+
+ &[data-slot="usage-date"] {
+ color: var(--color-text-muted);
+ }
+
+ &[data-slot="usage-model"] {
+ font-family: var(--font-sans);
+ color: var(--color-text-secondary);
+ max-width: 200px;
+ word-break: break-word;
+ }
+
+ &[data-slot="usage-cost"] {
+ color: var(--color-text-muted);
+ }
+
+ [data-slot="tokens-with-breakdown"] {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ }
+
+ [data-slot="breakdown-button"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ background: transparent;
+ border: none;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: color 0.15s ease;
+
+ &:hover {
+ color: var(--color-text);
+ }
+
+ svg {
+ width: 16px;
+ height: 16px;
+ }
+ }
+
+ [data-slot="breakdown-popup"] {
+ position: absolute;
+ left: 0;
+ top: 100%;
+ margin-top: var(--space-2);
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ padding: var(--space-2);
+ z-index: 10;
+ min-width: 180px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ font-size: var(--font-size-xs);
+
+ @media (prefers-color-scheme: dark) {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ }
+ }
+ }
+
+ tbody tr:last-child td {
+ border-bottom: none;
+ }
+ }
+
+ /* Pagination */
+ [data-slot="pagination"] {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-2);
+ padding: var(--space-4) 0;
+ border-top: 1px solid var(--color-border-muted);
+ margin-top: var(--space-2);
+
+ button {
+ padding: var(--space-2) var(--space-4);
+ background: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ svg {
+ width: 16px;
+ height: 16px;
+ stroke-width: 2;
+ }
+
+ &:hover:not(:disabled) {
+ background: var(--color-bg-tertiary);
+ border-color: var(--color-border-hover);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+ }
+
+ /* Mobile responsive */
+ @media (max-width: 40rem) {
+ [data-slot="usage-table-element"] {
+ th,
+ td {
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-xs);
+ }
+
+ /* Hide Model column on mobile */
+ th:nth-child(2),
+ td:nth-child(2) {
+ display: none;
+ }
+ }
+ }
+
+ /* Breakdown popup content */
+ [data-slot="breakdown-row"] {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-4);
+ padding: var(--space-1) 0;
+ }
+
+ [data-slot="breakdown-label"] {
+ color: var(--color-text-muted);
+ font-size: var(--font-size-xs);
+ }
+
+ [data-slot="breakdown-value"] {
+ color: var(--color-text);
+ font-weight: 500;
+ font-size: var(--font-size-xs);
+ }
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx
new file mode 100644
index 000000000..2cf8ef850
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx
@@ -0,0 +1,217 @@
+import { Billing } from "@opencode-ai/console-core/billing.js"
+import { createAsync, query, useParams } from "@solidjs/router"
+import { createMemo, For, Show, Switch, Match, createEffect, createSignal } from "solid-js"
+import { formatDateUTC, formatDateForTable } from "../../common"
+import { withActor } from "~/context/auth.withActor"
+import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
+import styles from "./usage-section.module.css"
+import { createStore } from "solid-js/store"
+import { useI18n } from "~/context/i18n"
+
+const PAGE_SIZE = 50
+
+async function getUsageInfo(workspaceID: string, page: number) {
+ "use server"
+ return withActor(async () => {
+ return await Billing.usages(page, PAGE_SIZE)
+ }, workspaceID)
+}
+
+const queryUsageInfo = query(getUsageInfo, "usage.list")
+
+export function UsageSection() {
+ const params = useParams()
+ const i18n = useI18n()
+ const usage = createAsync(() => queryUsageInfo(params.id!, 0))
+ const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
+ const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
+
+ createEffect(() => {
+ setStore({ usage: usage() })
+ }, [usage])
+
+ createEffect(() => {
+ if (!openBreakdownId()) return
+
+ const handleClickOutside = (e: MouseEvent) => {
+ const target = e.target as HTMLElement
+ if (!target.closest('[data-slot="tokens-with-breakdown"]')) {
+ setOpenBreakdownId(null)
+ }
+ }
+
+ document.addEventListener("click", handleClickOutside)
+ return () => document.removeEventListener("click", handleClickOutside)
+ })
+
+ const hasResults = createMemo(() => store.usage && store.usage.length > 0)
+ const canGoPrev = createMemo(() => store.page > 0)
+ const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
+
+ const calculateTotalInputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
+ return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0)
+ }
+
+ const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
+ return u.outputTokens + (u.reasoningTokens ?? 0)
+ }
+
+ const goPrev = async () => {
+ const usage = await getUsageInfo(params.id!, store.page - 1)
+ setStore({
+ page: store.page - 1,
+ usage,
+ })
+ }
+ const goNext = async () => {
+ const usage = await getUsageInfo(params.id!, store.page + 1)
+ setStore({
+ page: store.page + 1,
+ usage,
+ })
+ }
+
+ return (
+ <section class={styles.root}>
+ <div data-slot="section-title">
+ <h2>{i18n.t("workspace.usage.title")}</h2>
+ <p>{i18n.t("workspace.usage.subtitle")}</p>
+ </div>
+ <div data-slot="usage-table">
+ <Show
+ when={hasResults()}
+ fallback={
+ <div data-component="empty-state">
+ <p>{i18n.t("workspace.usage.empty")}</p>
+ </div>
+ }
+ >
+ <table data-slot="usage-table-element">
+ <thead>
+ <tr>
+ <th>{i18n.t("workspace.usage.table.date")}</th>
+ <th>{i18n.t("workspace.usage.table.model")}</th>
+ <th>{i18n.t("workspace.usage.table.input")}</th>
+ <th>{i18n.t("workspace.usage.table.output")}</th>
+ <th>{i18n.t("workspace.usage.table.cost")}</th>
+ <th>{i18n.t("workspace.usage.table.session")}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <For each={store.usage}>
+ {(usage, index) => {
+ const date = createMemo(() => new Date(usage.timeCreated))
+ const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage))
+ const totalOutputTokens = createMemo(() => calculateTotalOutputTokens(usage))
+ const inputBreakdownId = `input-breakdown-${index()}`
+ const outputBreakdownId = `output-breakdown-${index()}`
+ const isInputOpen = createMemo(() => openBreakdownId() === inputBreakdownId)
+ const isOutputOpen = createMemo(() => openBreakdownId() === outputBreakdownId)
+ const isClaude = usage.model.toLowerCase().includes("claude")
+ return (
+ <tr>
+ <td data-slot="usage-date" title={formatDateUTC(date())}>
+ {formatDateForTable(date())}
+ </td>
+ <td data-slot="usage-model">{usage.model}</td>
+ <td data-slot="usage-tokens">
+ <div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
+ <button
+ data-slot="breakdown-button"
+ onClick={(e) => {
+ e.stopPropagation()
+ setOpenBreakdownId(isInputOpen() ? null : inputBreakdownId)
+ }}
+ >
+ <IconBreakdown />
+ </button>
+ <span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span>
+ <Show when={isInputOpen()}>
+ <div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
+ <div data-slot="breakdown-row">
+ <span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.input")}</span>
+ <span data-slot="breakdown-value">{usage.inputTokens}</span>
+ </div>
+ <div data-slot="breakdown-row">
+ <span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.cacheRead")}</span>
+ <span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
+ </div>
+ <Show when={isClaude}>
+ <div data-slot="breakdown-row">
+ <span data-slot="breakdown-label">
+ {i18n.t("workspace.usage.breakdown.cacheWrite")}
+ </span>
+ <span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
+ </div>
+ </Show>
+ </div>
+ </Show>
+ </div>
+ </td>
+ <td data-slot="usage-tokens">
+ <div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
+ <button
+ data-slot="breakdown-button"
+ onClick={(e) => {
+ e.stopPropagation()
+ setOpenBreakdownId(isOutputOpen() ? null : outputBreakdownId)
+ }}
+ >
+ <IconBreakdown />
+ </button>
+ <span onClick={() => setOpenBreakdownId(null)}>{totalOutputTokens()}</span>
+ <Show when={isOutputOpen()}>
+ <div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
+ <div data-slot="breakdown-row">
+ <span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.output")}</span>
+ <span data-slot="breakdown-value">{usage.outputTokens}</span>
+ </div>
+ <div data-slot="breakdown-row">
+ <span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.reasoning")}</span>
+ <span data-slot="breakdown-value">{usage.reasoningTokens ?? 0}</span>
+ </div>
+ </div>
+ </Show>
+ </div>
+ </td>
+ <td data-slot="usage-cost">
+ <Switch fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}>
+ <Match when={usage.enrichment?.plan === "sub"}>
+ {i18n.t("workspace.usage.subscription", {
+ amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
+ })}
+ </Match>
+ <Match when={usage.enrichment?.plan === "lite"}>
+ {i18n.t("workspace.usage.lite", {
+ amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
+ })}
+ </Match>
+ <Match when={usage.enrichment?.plan === "byok"}>
+ {i18n.t("workspace.usage.byok", {
+ amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
+ })}
+ </Match>
+ </Switch>
+ </td>
+ <td data-slot="usage-session">{usage.sessionID?.slice(-8) ?? "-"}</td>
+ </tr>
+ )
+ }}
+ </For>
+ </tbody>
+ </table>
+ <Show when={canGoPrev() || canGoNext()}>
+ <div data-slot="pagination">
+ <button disabled={!canGoPrev()} onClick={goPrev}>
+ <IconChevronLeft />
+ </button>
+ <button disabled={!canGoNext()} onClick={goNext}>
+ <IconChevronRight />
+ </button>
+ </div>
+ </Show>
+ </Show>
+ </div>
+ </section>
+ )
+}